Skip to content

feat(mobile): glassmorphic root headers with auto-hiding chrome - #14575

Merged
dylanjeffers merged 1 commit into
mainfrom
feat/mobile-glass-headers
Aug 24, 2026
Merged

feat(mobile): glassmorphic root headers with auto-hiding chrome#14575
dylanjeffers merged 1 commit into
mainfrom
feat/mobile-glass-headers

Conversation

@dylanjeffers

Copy link
Copy Markdown
Contributor

Gives the root tab screens a frosted header that content scrolls behind — matching the desktop client's Frosted surface — and lets the top and bottom chrome get out of the way as you scroll.

In scope: Feed, Trending, Notifications.
Deliberately not: Explore (keeps its own search header) and Library (its material top-tab bar is opaque and shared with Profile, so the glass had a visible seam — left exactly as it was).

How it fits together

  • GlassSurface — the shared frosted surface. Real BlurView on iOS; on Android a near-opaque tint instead, matching the existing ProfileNavOverlay precedent, since Android blur on a surface composited every scroll frame is a real perf risk.
  • GlassChromeContext — owns a single scrollY shared value. The separator fade, the header translate and the tab bar translate all read from it, so they can't drift out of sync.
  • Floating headersheaderTransparent, so screens pad their own scroll content by the measured stack height. Measured rather than computed because the safe-area inset varies per device and OtaUpdateBanner adds a row conditionally.
  • Floating tab bar — now absolutely positioned. In normal flow the navigator reserved its height, so hiding it only uncovered dead background; content now occupies that space and slides behind the glass.

PlayBarChinBottomChin

The rename carries a behaviour change worth reviewing. It previously reserved only PLAY_BAR_HEIGHT; with the tab bar out of flow it now covers both bars. Without that, the end of every list would sit behind the tab bar permanently — worst on short lists, which never scroll far enough for the auto-hide to uncover them. This is about the end of the content being reachable, not about avoiding overlap mid-scroll: content still passes under the glass while scrolling, which is the point.

It also touches three Library files and FanClubsExploreScreen — import lines only, no behaviour change there.

Testing

  • Verified on iOS simulator (iPhone 17 Pro, light mode): all three screens, neutral and scrolled, header + tab bar hide on scroll-down and return on scroll-up.
  • tsc --noEmit clean, eslint clean.
  • Not yet verified: Android, and dark mode. The Android tint fallback and the dark blurType branch are both unexercised paths. Dark-mode pass is in progress.

No changeset, on purpose

No SDK surface, and a '@audius/mobile' bump moves the OTA release-history routing key — that's what broke production OTA in August (see #14564).

⚠️ Note this ships via OTA, which is currently not reaching production. production/1.5.185 (what iPhones poll) hasn't had a release since 2026-07-28. Merging this publishes to rc only; it needs #14564 merged and a manual production dispatch to reach users.

🤖 Generated with Claude Code

Give the root tab screens a frosted header that content scrolls behind,
matching the desktop client's `Frosted` surface, and let the whole top and
bottom chrome get out of the way as you scroll.

In scope: Feed, Trending, Notifications. Explore keeps its own search header
and Library is deliberately untouched (its material top-tab bar is opaque and
shared with Profile, so the glass had a visible seam there).

How it fits together:

- `GlassSurface` is the shared frosted surface. Real `BlurView` on iOS; on
  Android a near-opaque tint instead, matching the existing `ProfileNavOverlay`
  precedent — Android blur on a surface composited every scroll frame is a
  real perf risk.
- `GlassChromeContext` owns one `scrollY` shared value. Everything reacts to
  it: the separator fades in over the first 24px, and the header and tab bar
  translate away on scroll-down and return on scroll-up. One signal keeps them
  in sync rather than three that can drift.
- Headers float (`headerTransparent`), so screens pad their own scroll content
  by the measured stack height. Measured, not computed, because the safe-area
  inset varies per device and `OtaUpdateBanner` adds a row conditionally.
- The bottom tab bar is now positioned absolutely. In normal flow the navigator
  reserved its height, so hiding it only uncovered dead background; now content
  occupies that space and slides behind the glass.

`PlayBarChin` becomes `BottomChin` and now covers both bars. The name mattered:
it reserved only the play bar's height, and with the tab bar out of flow the
end of every list would otherwise sit behind it permanently — worst on short
lists, which never scroll far enough for the auto-hide to uncover them. This is
about the end of the content being reachable, not about avoiding overlap
mid-scroll; content still passes under the glass while scrolling.

No changeset on purpose: this has no SDK surface, and a `'@audius/mobile'` bump
moves the OTA release-history routing key (see #14564).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 08d471f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@dylanjeffers

Copy link
Copy Markdown
Contributor Author

Dark mode pass — ✅ passes, no code changes needed

Verified on iOS simulator by switching system appearance to dark (theme mode follows system).

  • Header, sub-header pills and the bottom tab bar all render correctly on the dark palette.
  • Content visibly passes behind the glass while scrolling.
  • Auto-hide and the scroll-linked separator behave the same as in light mode.

Also checked the theme matrix rather than just light/dark: isDarkTheme covers all five resolved variants (default-light, default-dark, classic-light, classic-dark, matrix), with matrix correctly classed as dark. GlassSurface reads its tint from backgroundSurface on whichever palette is active, so matrix picks up its own surface colour with no special-casing.

One optional refinement, not blocking: the bottom separator (borderStrong) is quite subtle against the dark glass. It reads fine, but could be nudged if you want the layer edge more defined in dark.

Still unverified: Android. The Platform.OS tint fallback in GlassSurface and the tab bar translate are both unexercised there.

@dylanjeffers
dylanjeffers merged commit 0cfaaca into main Aug 24, 2026
3 checks passed
@dylanjeffers
dylanjeffers deleted the feat/mobile-glass-headers branch August 24, 2026 23:16
dylanjeffers added a commit that referenced this pull request Aug 24, 2026
Web Tests failed on responsiveCoverage.test.ts: the Discover Weekly page
uses TrackTableLineup but had no entry in RESPONSIVE_TABLE_POLICIES, and
that test governs the invariant that every shared-table consumer declares
one. Adds a discoverWeeklyTracks policy, registers the page in the
audited consumer list, and actually passes the policy to the table --
without that last part the entry would have satisfied the test while the
table stayed non-responsive.

Merge conflict in AppTabScreen.tsx was purely indentation: #14575
re-indented the Stack.Screen block. Took main's formatting and re-added
the DiscoverWeeklyScreen registration at the matching depth.

Full web suite passes locally (152 passed, 9 skipped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dylanjeffers added a commit that referenced this pull request Aug 25, 2026
Follow-up to #14575, from the Android pass. **This is a real defect in
what we just merged.**

## The bug

Android has no backdrop blur, so `GlassSurface` fell back to a `0.94`
tint to keep a hint of translucency. That only works in light mode.

Translucency reads as *frost* when something blurs what shows through.
With no blur, whatever bleeds through keeps its edges and reads as
legible ghost text sitting on top of the header. And the same alpha
lands completely differently depending on the surface:

| | glass | ghost peak | relative contrast |
|---|---|---|---|
| Light | RGB 248 | 229 | ~8% |
| Dark | RGB 14 | 27 | **~93%** |

6% of near-white content over a near-black surface nearly doubles the
luminance. In dark mode, track titles, artists, durations and play
counts were plainly readable behind the header.

## The fix

Android is now fully opaque — which is what the `ProfileNavOverlay`
fallback this was meant to follow already does (`backgroundColor:
backgroundSurface`, no opacity). I cited that precedent in #14575 and
then didn't actually follow it; that was the bug.

No single alpha suits both themes, so Android trades the hint of
translucency for a clean surface. Content still slides under the header,
it just isn't see-through. **iOS is unchanged** and keeps its real blur.

## Verification

Built `assembleProdDebug` and ran on a Pixel 8 Pro (API 35) emulator in
dark mode with real content scrolled behind the header — the surface is
now clean, and content clips at the glass edge instead of bleeding
through. `tsc` 0 errors, `eslint` clean.

Also confirmed on Android during the same pass, unchanged by this fix:
tab bar position/elevation and z-order against the play bar, now-playing
drawer and left nav drawer; auto-hide of both bars; and content offset
(at max scroll the last item lands exactly at the play bar top).

## Known, not addressed here

The play bar doesn't participate in auto-hide, so hiding the chrome
mid-playback leaves it floating with an empty band beneath it. Not
Android-specific and pre-existing from #14575 — worth a separate change
if we want the bottom chrome to move as one unit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
dylanjeffers added a commit that referenced this pull request Aug 25, 2026
…14577)

Follow-up to #14575. The now-playing bar didn't participate in the
auto-hide, so scrolling down with a track playing left it stranded — the
tab bar floats away beneath it and the play bar stayed put with an empty
band underneath.

It now travels on the same `useTabBarHiddenProgress` the tab bar uses,
so the two move as one unit rather than on two signals that can drift.

## Why the whole drawer moves, not the play bar

My first attempt translated just the play bar. Both bars hid — and it
left an **empty white band** instead. The white surface behind the play
bar belongs to the drawer root, so moving only its contents slid the bar
off a background that stayed put. Same problem, different colour.

So this moves the drawer. Two consequences worth reviewing:

- **Wrapping the drawer creates a new stacking context**, so its z-order
is restated on the wrapper, and `pointerEvents='box-none'` keeps the
full-screen wrapper from swallowing touches meant for the screen behind
it. This is the same class of thing the tab bar work already had to
handle.
- **Pinned to `0` while the drawer is open** — that same drawer is the
full-screen player, and translating it there would drag the expanded
player off screen.

## Verification (iOS simulator, track actually playing)

- Chrome hides and restores as one unit, no empty band.
- Full-screen player still opens correctly through the wrapper.
- Swipe-to-dismiss still works.
- Left nav drawer still pushes the play bar with the screen rather than
being punctured by its elevation — the specific z-order risk of
wrapping.
- No crashes across the run.

`tsc` 0 errors, `eslint` clean.

## Not verified

**Android.** This is a shared, gesture-heavy component and Android
resolves z-order via `elevation` rather than `zIndex`, which is exactly
where the wrapper could behave differently. Worth an Android check
before or shortly after merge.

## Unrelated, but noticed

Two `SIGABRT`s occurred on the simulator during this session, both
`ShadowTree::commit` asserts inside
`reanimated::ReanimatedMountHook::shadowTreeDidMount`. Both followed
Fast Refresh reload cycles; clean launches have been stable through
playback, scrolling, drawer open/close and tab switching. Likely a
dev-only artifact — that assert is typically compiled out in release —
but noting it since it first appeared during the #14575 reanimated work
rather than before it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant